#!/usr/bin/python
#
#  xcarlinktunes.py
#  
#
#  Created by Tomasz Blaszczyk.
#  tomek_blaszczyk@yahoo.com
#  http://bla.pl
#

# ---- User - configurable data ----------
#
# A list of iTunes playlists you want to put into
# XCarLink.
# Each playlist will start in a new CD.
# Playlists longer than 'maxSongsInCD' will be broken
# into multiple CDs. 
playlistNames	= [ \
	"xcarlink1", "xcarlink2" \
	]
# set this var if you want to access iTunes XML from
# a different location; None means 'default'
musicLibName		= None
#
# Insert SD card. When it appears as Mac volume,
# change it's name to "CAR8" or any name you like.
# Put the name here:
outputDir = "/Volumes/CAR8"
#
# Maximum number of CDs your radio will recognize
# After connecting to XCarLink. Honda Accord
# recognizes 16 virtual CDs.
maxCD			= 16
#
# Max number of songs in CD. XCarLink does not support
# more than 99. You can set it to lower number.
maxSongsInCD	= 99
#
# ---- end of user configurable data -----

#
# This is cache info file -- to enable re-use of files
# already on SD card. Remove this file from SD card if you want
# to make sync from scratch
songsInfoFile	= ".songs.ifo"
listingFile	= "cd_listing.txt"

import os
import sys
import pickle
import urllib
import shutil
import codecs
import plistlib

#---------------------------------------------------
class PyTuna:
#---------------------------------------------------
	"""PyTuna is Python iTunes Access.
	It is a wrapper class
	to allow for a convenient data retrieval.
	"""
	
	def __init__(self, name):
		"""constructor takes iTunes library name.
		If not supplied, the default location will be used."""
		if name is None:
			name = os.path.join("$HOME", "Music", "iTunes", \
				"iTunes Music Library.xml");
			name = os.path.expandvars(name);
		self.tunes = plistlib.readPlist(name);
		# This will be built on demand.
		self.persistentIdDict = None
		
	def findPersistentId(self, songId):
		"""For the given song Id, find it's persistent Id"""
		s = self.findSong(songId)
		pid = s["Persistent ID"]
		return pid
		
	def findSongId(self, persistentId):
		"""Find song id based on persistent Id."""
		# As this information is not directly available,
		# reverese-mappng dictionary is built first.
		if self.persistentIdDict is None:
			self.buildPersistentIdDict()
		return self.persistentIdDict[persistentId]
		
	def findSong(self, songId):
		"""Find song description for the given song id"""
		asText = str(songId)
		tracks = self.tunes["Tracks"]
		return tracks[asText]
		
	def findSongSize(self, songId):
		"""Find song's length in bytes for song id"""
		s = self.findSong(songId)
		return s["Size"]
		
	def findSongLocation(self, songId):
		"""Find location URL of song for song id"""
		s = self.findSong(songId)
		return s["Location"]
		
	def findSongTitle(self, songId):
		"""Retrieve title of the given song id"""
		s = self.findSong(songId)
		return s["Name"]
		
	def findPlaylistItems(self, pname):
		"""Returns a list of song id's for the given playlist name."""
		playlists = self.tunes["Playlists"]
		result = []
		for p in playlists:
			if p["Name"]==pname:
				# Playlist Items is an array of 1-attribute
				# dictionaries, each bearing just "Track ID"
				itemarray = p["Playlist Items"]
				for item in itemarray:
					result.append(item["Track ID"])
				return result;
		return None
		
	def findSongArtistAlbum(self, songId):
		"""For the given song id, find 'Album Artist' or 'Artist'
		and merge it with album name."""
		s = self.findSong(songId)
		if "Album Artist" in s:
			artist = s["Album Artist"]
		else:
			artist = s["Artist"]
		album = s["Album"]
		return artist+"/"+album
	
	def buildPersistentIdDict(self):
		"""Prepare reverse-lookup dictionary, so it's easy
		to find song id based on persistent id."""
		tracks = self.tunes["Tracks"]
		self.persistentIdDict = {}
		for song in tracks.values():
			sid = song["Track ID"]
			pid = song["Persistent ID"]
			self.persistentIdDict[pid] = sid
#----end of class PyTuna ---------------------------------


def getCdName(i):
	"""Turn 0-based index of CD dir into it's name, e.g.
	for i==0 it's 'CD01'"""
	return "CD{0:02d}".format(i+1);
	
def getSongFileName(i):
	"""Turn 0-based index of song into file name, e.g.
	for i==0 it's 'SONG001.mp3'"""
	return "SONG{0:03d}.mp3".format(i+1)

def toMiB(numBytes):
	"""Turn number of bytes into string representing milions
	of bytes (MiB). MiB is widely used by HDD manufacturers,
	1MiB is 1000*1000B, while 1MB is 1024*1024B"""
	return "{0:d} MiB".format(numBytes/10**6)

def checkDestinationDirExists():
	"""Checks if the destination directory exists."""
	if not os.path.exists(outputDir):
		print "ERROR: ", outputDir, "does not exist."
		print "Exiting..."
		sys.exit(1)

def constructCdList(itlib, playlistNames):
	"""For the given loaded iTunes library 'itlib' and given
	list of iTunes playlists, fetch song info and break each
	iTunes playlist into 'maxSongsInCD' chunks (99 by default).
	The result is an array of maximum 'maxCD' elements. Each
	element is a list of song id's and describes one virtual CD.
	Please note that each playlists starts a new CD. If you want
	to fill-up space to maximim, use just 1 iTunes playlists with
	all the songs you want to move to xcarlink."""
	cdList = []
	for playlist in playlistNames:
		# get a list of song id's for the given playlist name
		itPlaylist = itlib.findPlaylistItems(playlist)
		if itPlaylist is None:
			print "ERROR: playlist '"+playlist+ \
					"' not found in iTunes. Exiting."
			sys.exit(1)
		# check songs
		for itItem in itPlaylist:
			s = itlib.findSong(itItem)
			if s["Kind"] != "MPEG audio file":
				print "ERROR: following song is not mp3. Exiting."
				print s
				sys.exit(1)
			if s["Track Type"] != "File":
				print "ERROR: following song is not a local file. Exiting."
				print s
				sys.exit(1)
		# divide iTunes playlist into chunks
		while(len(itPlaylist)>0):
			cdList.append(itPlaylist[:maxSongsInCD])
			itPlaylist = itPlaylist[maxSongsInCD:]
	# check how many directories will be needed
	if (len(cdList)>maxCD):
		print "ERROR: your playlists created",len(cdList),"CDs."
		print "Max allowed number of CDs is",maxCD,". Exiting..."
		sys.exit(1);
	return cdList

def checkSpaceNeeded(destDir, itlib, cdList):
	"""Checks if sync will succeedd, by computing the available
	space in the destination disk, including space taken up by
	CD* folders."""
	# find free space on destDir
	destStat = os.statvfs(destDir)
	destFree = destStat.f_frsize * destStat.f_bavail
	# find space occupied by CD* directories
	destOccupied = 0
	for i in range(maxCD):
		cdFullName = os.path.join(destDir, getCdName(i))
		for (root, dirs, files) in os.walk(cdFullName):
			for name in files:
				destOccupied += \
					os.path.getsize(os.path.join(root, name))
	# find space needed by files from cdList
	needed = 0;
	for cd in cdList:
		for songId in cd:
			needed += itlib.findSongSize(songId)
	if (needed >= destFree+destOccupied-10**6):
		print "ERROR: playlists need", toMiB(needed+10**6)+", but", \
			"free space is", toMiB(destFree), " and CD* directories", \
			"occupy", toMiB(destOccupied), "so after cleanup only", \
			toMiB(destFree+destOccupied),"will be available."
		print "Exiting..."
		sys.exit(1)
	print "  INFO:", toMiB(needed+10**6), "needed", \
		toMiB(destFree), "free,", \
		toMiB(destOccupied), "will be freed."

#---------------------------------------------------
class CacheManager:
#---------------------------------------------------
	"""The class takes care of re-using songs already present
	in the destination dir based on the info file 'cacheFileName'
	created during previous sync."""
	
	def __init__(self, itlib, outputDirName, cacheFileName):
		"""itlib is PyTuna iTunes library
		outputDirName is destination disk name
		chacheFileName is previously created info file name."""
		self.itlib = itlib
		self.odir = outputDirName
		self.cacheDir = "CACHE"
		self.cacheFile = cacheFileName
		# origMap is songId -> CD*/SONG* map
		self.origMap = {}
		# cache map is songId -> CACHE/C* map
		self.cacheMap = {}
		self.loadCacheInfo()
		
	def loadCacheInfo(self):
		"""Loads songs info file. As song id might have changed
		since last run, the file holds persistent id. Once info
		is loaded, persistent id's are converted into song id's."""
		cf = os.path.join(self.odir, self.cacheFile)
		if os.path.exists(cf):
			# Load a previously created dictionary with persistent ids
			f=open(cf,'r')
			persMap = pickle.load(f)
			f.close()
			# convert persistent ids into song ids
			for persId in persMap.keys():
				val = persMap[persId];
				sid = self.itlib.findSongId(persId)
				self.origMap[sid] = val
			self.makeCacheDir()
		else:
			# no previous info found - original cache map is empty
			self.origMap = {}
			
	def makeCacheDir(self):
		"""Create temporary cache dir in the destination drive."""
		cd = os.path.join(self.odir, self.cacheDir)
		if not os.path.exists(cd):
			os.mkdir(cd)
			
	def moveToCache(self, songId):
		"""Save given song to cache, so we do not need to
		sync it."""
		if songId in self.origMap:
			# song shall be present in dest/dir/name
			(dir, name) = self.origMap[songId]
			# this cache entry has been used, remove
			del self.origMap[songId]
			# construct song name
			sname = os.path.join(self.odir, dir, name);
			# construct cached name
			cachedName = "C{0:d}.mp3".format(songId)
			dname = os.path.join( \
				self.odir, self.cacheDir, cachedName)
			# song might have been removed from the source dir...
			if (os.path.exists(sname)):
				#...so move it only when it exists
				os.rename(sname, dname)
				# and note is in cache info
				self.cacheMap[songId] = cachedName
				
	def isCached(self, songId):
		"""Checks if given song has been put to cache"""
		return songId in self.cacheMap
		
	def moveFromCache(self, songId, destCd, destName):
		"""Move given song from cache to the new destination
		folder and new name."""
		srcName = self.cacheMap[songId]
		# song no longer in cache, delete info
		del self.cacheMap[songId]
		sname = os.path.join(self.odir, self.cacheDir, srcName)
		dname = os.path.join(self.odir, destCd, destName)
		os.rename(sname, dname)
		
	def removeCacheDir(self):
		"""Cleanup - remove temporary CACHE dir
		in the destination drive."""
		cd = os.path.join(self.odir, self.cacheDir)
		if os.path.exists(cd):
			shutil.rmtree(cd)
			
	def saveCacheInfo(self, itlib, cdList):
		"""Create song map info for future sync, using
		persistent ids."""
		# dictionary holding persistentId->(dir,name)
		d = {}
		# CD index
		cidx = 0
		for cd in cdList:
			cdName = getCdName(cidx)
			# song index
			sidx = 0
			for songId in cd:
				pid = itlib.findPersistentId(songId)
				songName = getSongFileName(sidx)
				d[pid] = (cdName, songName)
				sidx += 1
			cidx += 1
		# once mapping in 'd' has been created, serialize.
		f = open(os.path.join(self.odir, self.cacheFile), 'w')
		pickle.dump(d, f)
		f.close()
		
	def prepareCache(self, cdList):
		"""Move songs to cache. Only songs present in new
		CD structure are saved."""
		for cd in cdList:
			for song in cd:
				self.moveToCache(song)
				
	def processFilesInCache(self, cdList):
		"""Move all the files that has been cached to a new
		destination CD / SONG. Return cdList modified in such
		a way, that for cached songs no song id is present, but
		'None' value. The returned list is a list of songs
		that requires full copies from your computer to 
		the destination drive."""
		cached = 0
		notcached = 0
		newCdList = []
		for cdIdx in range(len(cdList)):
			cd = cdList[cdIdx];
			cdName = getCdName(cdIdx)
			newSubList = []
			for songIdx in range(len(cd)):
				songId = cd[songIdx]
				if self.isCached(songId):
					# This has been cached -- move it to the right place
					songName = getSongFileName(songIdx)
					self.moveFromCache(songId, cdName, songName)
					cached += 1
					# Note as processed
					newSubList.append(None)
				else:
					notcached += 1
					# Note as unprocessed -- leave sond id
					newSubList.append(songId)
			newCdList.append(newSubList)
		print "Info: re-used", cached, "files,", notcached,"to go."
		return newCdList
		
#-- end of class CacheManager ------------------------------


def clearCdDirectories(outputDir, maxCD):
	"""Removes CD* directories in the destination drive
	and creates them from scratch (empty ones)."""
	for i in range(maxCD):
		cdName = getCdName(i)
		fullCdName = os.path.join(outputDir, cdName)
		if os.path.exists(fullCdName):
			shutil.rmtree(fullCdName)
		os.mkdir(fullCdName)

def calculateSize(itlib, cdList):
	"""Calculate size of the unprocessed songs."""
	size = 0
	for cd in cdList:
		for songId in cd:
			if not songId is None:
				size += itlib.findSongSize(songId)
	print "Need to copy", toMiB(size), "of songs..."
	return size

def ticker(totalSize, copiedSoFar):
	"""Prints progress."""
	if (totalSize <= 0):
		percent = 100.0
	else:
		percent = copiedSoFar*100.0/totalSize
	print " Copied {0:.1f}%\r".format(percent), 
	sys.stdout.flush()

def copySongs(itlib, outDir, totalSize, cdList):
	"""Copies songs from computer to the destination drive."""
	if (totalSize<=0):
		print "  Info: nothing to copy."
		return
	copiedSize = 0
	for cdIdx in range(len(cdList)):
		cdName = getCdName(cdIdx);
		sList = cdList[cdIdx]
		print "Taking care of", cdName
		for sIdx in range(len(sList)):
			songId = sList[sIdx];
			if songId is None:
				# this position has been taken care of using
				# cache.
				pass
			else:
				# open file-like object for song's URL.
				ssize = itlib.findSongSize(songId)
				srcUrl = itlib.findSongLocation(songId)
				srcFile = urllib.urlopen(srcUrl)
				songFileName = getSongFileName(sIdx)
				# open real file on the destination drive
				destName = os.path.join(outDir, cdName, songFileName)
				destFile = open(destName,'w')
				# copy file-like URL to the destination file.
				shutil.copyfileobj(srcFile, destFile)
				srcFile.close()
				destFile.close()
				copiedSize += ssize
				# print progress
				ticker(totalSize, copiedSize)
				

#---------------------------------------------------
class AlbumManager:
#---------------------------------------------------
	"""Class that retrieves albums for songs and maintains
	short album codes."""
	def __init__(self, itlib, cdList):
		"""Create album info for given PyTuna iTunes lib
		and cd layout."""
		self.albumsCodes = {}
		self.codes = []
		self.codesAlbums = {}
		self.songAlbums = {}
		self.itlib = itlib
		self.cdList = cdList
		self.createAlbums()
		self.dashSize = 40
		
	def addAlbum(self, songId, a):
		"""For the given song id and it's long album name 'a'
		create and return short album code."""
		if a not in self.albumsCodes:
			newCode = "A{0:03d}".format(len(self.codes)+1)
			self.codes.append(newCode)
			self.albumsCodes[a] = newCode
			self.codesAlbums[newCode] = a;
		self.songAlbums[songId] = self.albumsCodes[a]
		return self.albumsCodes[a]
		
	def getCodes(self):
		"""Return a list of all short album codes."""
		return self.codes
		
	def getSongAlbum(self, songId):
		"""Find short album id for the given song id."""
		return self.songAlbums[songId]
		
	def getAlbum(self, code):
		"""Find long album name for the given short album code."""
		return self.codesAlbums[code]
		
	def createAlbums(self):
		"""Create album info for all songs."""
		for cd in self.cdList:
			for songId in cd:
				album = self.itlib.findSongArtistAlbum(songId)
				self.addAlbum(songId, album)
					
	def ddash(self, t):
		"""Create dashed line of the same length as 't'."""
		head = '='*self.dashSize
		return head + '\n' + t + '\n' + head + '\n'
		
	def cdHead(self, t):
		"""Prepare header for pretty printing."""
		lower = "==    " + t + "    =="
		upper = '-'*len(lower)
		return upper + '\n' + lower + '\n'
		
	def printAlbums(self, f):
		"""Show a list of all albums."""
		f.write(self.ddash("ALBUMS"));
		for code in self.getCodes():
			a = self.getAlbum(code)
			f.write(code+" "+a+"\n")
			
	def printCdAlbums(self, f):
		"""Write CD info noting track ranges for each album."""
		f.write(self.ddash("ALBUMS in CDs"));
		i = 0;
		for cd in self.cdList:
			f.write(self.cdHead(getCdName(i)))
			if len(cd)<1:
				f.write("<none>\n")
			else:
				self.printCdAlbumsOneList(f, cd)
			i += 1
			
	def printCdAlbumsOneList(self, f, oneCd):
		"""Print track ranges for albums in given CD."""
		assert len(oneCd)>0
		minIdx = 1;
		maxIdx = 1;
		acode = self.getSongAlbum(oneCd[0])
		idx = 1;
		while (idx<len(oneCd)):
			acode2 = self.getSongAlbum(oneCd[idx]);
			if acode==acode2:
				# album continues...
				maxIdx = idx+1;
			else:
				# album changed -- print track range for
				# the previous one
				self.printAlbumTrackRange(f, \
						minIdx, maxIdx, acode);
				minIdx = idx+1
				maxIdx = minIdx
				acode = acode2
			idx += 1
		# and do not forget the last batch of indices :-)
		self.printAlbumTrackRange(f, minIdx, maxIdx, acode)
		
	def printAlbumTrackRange(self, f, minIdx, maxIdx, acode):
		"""Prints track range for the given album."""
		f.write("{0:02d}-{1:02d} ".format(minIdx, maxIdx));
		f.write(self.getAlbum(acode)+"\n")
		
	def printDetailedContent(self, f):
		"""Print each song's name in each CD along with short
		album code."""
		f.write(self.ddash("SONGS in CDs"));
		i = 0;
		for cd in self.cdList:
			f.write(self.cdHead(getCdName(i)))
			self.printDetailedContentOneList(f, cd)
			i += 1
			
	def printDetailedContentOneList(self, f, cd):
		"""Print each song's name in the given CD along with
		short album code."""
		i = 0;
		for songId in cd:
			i += 1
			title = self.itlib.findSongTitle(songId)
			acode = self.getSongAlbum(songId)
			f.write("{0:02d} ".format(i))
			f.write(acode+" "+title+"\n")
			
	def printAll(self, f):
		"""Prints album listing, short- and long-form CD contents."""
		self.printAlbums(f)
		f.write("\n")
		self.printCdAlbums(f)
		f.write("\n")
		self.printDetailedContent(f)
		
# --- end of class AlbumManager ------------------


# ------------------------------------------------
# main flow
# ------------------------------------------------

def main():
	global playlistNames, musicLibName, \
		outputDir, maxCD, maxSongsInCD

	print "Checking destination dir existence..."
	checkDestinationDirExists()

	print "Loading iTunes Library..."
	itlib = PyTuna(musicLibName)

	print "Processing playlists..."
	cdList = constructCdList(itlib, playlistNames)

	print "Checking free space in destination..."
	checkSpaceNeeded(outputDir, itlib, cdList)

	print "Loading song layout info..."
	cm = CacheManager(itlib, outputDir, songsInfoFile)

	print "Preparing cache..."
	cm.prepareCache(cdList)

	print "Cleaning up CD* directories..."
	clearCdDirectories(outputDir, maxCD)

	print "Distributing files from cache..."
	uncachedCdList = cm.processFilesInCache(cdList)
	cm.removeCacheDir()

	print "Calculating copy size..."
	totalSize = calculateSize(itlib, uncachedCdList);

	print "Copying remaining songs..."
	copySongs(itlib, outputDir, totalSize, uncachedCdList)

	print "Saving song layout info..."
	cm.saveCacheInfo(itlib, cdList)

	am = AlbumManager(itlib, cdList)
	print "Creating listing file '"+listingFile+"'..."
	f = codecs.open(listingFile, 'w', 'utf_8');
	am.printAll(f);
	f.close()

if __name__ == "__main__":
	main()

